Java 函数式编程常用套路(一)

本文我们着重来测试 Java 中常用基本流式编程算子。为了系统测试,我将整个测试单元划分成四个部分,分别是:

  • 基础分组与分区
  • 收集器下游变换
  • 数值聚合与统计
  • 多维高级矩阵


数据的准备

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Data
@Accessors(chain = true)
@NoArgsConstructor
@AllArgsConstructor
static class StaffPO {
private Long id; // 员工ID
private String name; // 姓名
private Long deptId; // 部门ID
private String deptName; // 部门名称
private Double salary; // 薪资
private String gender; // 性别:M-男, F-女
private Integer status; // 状态:0-离职, 1-在职
}

public static List<StaffPO> initData() {
return Arrays.asList(
// 1001 研发部(高薪资、男女混合、含离职)
new StaffPO(1L, "张三", 1001L, "研发部", 15000.0, "M", 1),
new StaffPO(2L, "李四", 1001L, "研发部", 18000.0, "M", 1),
new StaffPO(3L, "王五", 1001L, "研发部", 12000.0, "F", 0), // 离职

// 1002 测试部(薪资中等、全女性、均在职)
new StaffPO(4L, "赵六", 1002L, "测试部", 9000.0, "F", 1),
new StaffPO(5L, "孙七", 1002L, "测试部", 11000.0, "F", 1),

// 1003 HR部(薪资较低、含男女、含离职)
new StaffPO(6L, "周八", 1003L, "HR部", 6000.0, "M", 1),
new StaffPO(7L, "吴九", 1003L, "HR部", 8000.0, "F", 1),
new StaffPO(8L, "郑十", 1003L, "HR部", 5000.0, "M", 0) // 离职
);
}

private static List<StaffPO> staffList;
static {
staffList = initData();
}


基础分组与分区

基本分组和分区主要包含的算子是 groupingBy(分组)与 partitioningBy(分区)。它们俩是 Collectors 体系里用来分流数据的两大核心,但设计理念截然不同:

  • groupingBy:像多叉树,根据你指定的某个属性(Key),把数据分发到多个不同的盒子里,Key 的数量由数据决定。
  • partitioningBy:像二叉树(分水岭),只接收一个返回 boolean 的断言,无论数据怎么变,结果永远只有两个盒子:true 盒 和 false 盒。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
* 测试目标:groupingBy 基础多维分组
* 业务场景:把所有员工按照部门名称聚类归档。
* <p>
* 研发部: [张三, 李四, 王五]
* 测试部: [赵六, 孙七]
* HR部: [周八, 吴九, 郑十]
*/
@Test
public void testGroupingBy() {
// 聚类归档
Map<String, List<StaffPO>> deptNameAndStaffMap = staffList.stream()
.collect(Collectors.groupingBy(
StaffPO::getDeptName
));
// 打印验证
deptNameAndStaffMap.forEach((deptName, members) -> {
log.info("{}:{}", deptName, members.stream().map(StaffPO::getName).toList());
});
}

/**
* 测试目标:partitioningBy 状态二分区
* 业务场景:将员工划分为 “在职(status=1)”和“离职(status=0)”两大阵营。
* <p>
* groupingBy 的 Key 是动态生成的。如果流为空,得到的 Map 就是空的。
* partitioningBy 的 Key 永远只有 true 和 false。即使流为空,也会得到一个包含 true=[] 和 false=[] 的 Map。
* <p>
* 在职: [张三, 李四, 赵六, 孙七, 周八, 吴九]
* 离职: [王五, 郑十]
*/
@Test
public void testPartitioningBy() {
Map<Boolean, List<StaffPO>> statusPartitionMap = staffList.stream()
.collect(Collectors.partitioningBy(
staff -> staff.getStatus() == 1
));
statusPartitionMap.forEach((status, members) -> {
String statusName = status ? "在职" : "离职";
log.info("{}:{}", statusName, members.stream().map(StaffPO::getName).toList());
});
}


收集器下游变换

上面的测试,我们分流后的盒子里装的都是整个 StaffPO 大对象。但在实际生产中,这种写法往往会带来很多不必要的内存开销,或者无法直接满足报表输出需求,所以我们还会用到下游收集器(Downstream Collector)。这里我们会重点测试 groupingBy 的双参数重载版本:

$$\text{groupingBy(classifier, downstream)}$$

这里的 downstream(下游收集器)就像是安置在每个分组盒子门口的 “二次加工流水线”,对刚分好组的数据进行再次处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/**
* 测试目标:Collectors.groupingBy 及其下游收集器 Collectors
*/
@Test
public void test03() {
// 统计每个部门下面有多少个员工
Map<String, Long> deptAndStaffCountMap = staffList.stream()
.collect(Collectors.groupingBy(
StaffPO::getDeptName,
Collectors.counting() // 内置的下游收集器,专门用来数人头
));
deptAndStaffCountMap.forEach((deptName, staffCount) -> {
log.info("{} count {}", deptName, staffCount);
});

// 统计每个部门下面有哪些性别(测试去重)
Map<String, Set<String>> deptNameAndGenderMap = staffList.stream()
.collect(Collectors.groupingBy(
StaffPO::getDeptName,
Collectors.mapping( // 这里改用 toSet() 自动去重
StaffPO::getGender,
Collectors.toSet()
)
));
deptNameAndGenderMap.forEach((deptName, genderSet) -> {
log.info("{} 性别集合 {}", deptName, genderSet);
});

// 统计每个部门下面有那些人
Map<String, List<String>> deptNameAndStaffNamesMap = staffList.stream()
.collect(Collectors.groupingBy(
StaffPO::getDeptName,
Collectors.mapping(StaffPO::getName, Collectors.toList())
));
deptNameAndStaffNamesMap.forEach((deptName, staffNames) -> {
log.info("{} 成员列表: {}", deptName, staffNames);
});
}


数值聚合与统计

在 Java 8 之前,如果你想计算每个部门的平均薪资、最高薪资和薪资总和,你需要写大量冗长的循环、累加器和条件判断。而在 Stream 体系中,通过将 groupingBy 与数值收集器结合,一行代码就能搞定复杂的财务报表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
/**
* 测试目标:
* 目标一:计算每个部门的平均薪资
* 目标二:计算每个部门的平均薪资并排序(方法一、方法二)
*/
@Test
public void test04() {
// 统计每个部门的平均薪资
Map<String, Double> deptNameAndAverageSalaryMap = staffList.stream()
.collect(Collectors.groupingBy(
StaffPO::getDeptName,
Collectors.averagingDouble(StaffPO::getSalary)));
deptNameAndAverageSalaryMap.forEach((deptName, averageSalary) -> {
log.info("{} 平均薪资:{}", deptName, averageSalary);
});
System.out.println();

// 排序方法一:在 Stream 链条中一气呵成
LinkedHashMap<String, Double> deptNameAndSortedAverageSalaryMap = staffList.stream()
.collect(Collectors.groupingBy(
StaffPO::getDeptName,
Collectors.averagingDouble(StaffPO::getSalary)
)).entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.collect(Collectors.toMap(
Map.Entry::getKey, // keyMapper function
Map.Entry::getValue, // valueMapper function
(oldValue, newValue) -> oldValue, // merge function
LinkedHashMap::new // map supplier
));
deptNameAndSortedAverageSalaryMap.forEach((deptName, averageSalary) -> {
log.info("{} 排序后的平均薪资:{}", deptName, averageSalary);
});
System.out.println();

// 排序方法二:利用 collectingAndThen 在分组时直接套娃
LinkedHashMap<String, Double> deptNameAndSortedAverageSalaryMap2 = staffList.stream()
.collect(Collectors.collectingAndThen(
Collectors.groupingBy(
StaffPO::getDeptName,
Collectors.averagingDouble(StaffPO::getSalary)
),
deptAndSalaryMap -> deptAndSalaryMap.entrySet().stream()
.sorted((entry1, entry2) -> entry2.getValue().compareTo(entry1.getValue()))
.collect(Collectors.toMap(
Map.Entry::getKey,
Map.Entry::getValue,
(v1, v2) -> v1,
LinkedHashMap::new
))
));
deptNameAndSortedAverageSalaryMap2.forEach((deptName, averageSalary) -> {
log.info("{} 排序后的平均薪资2:{}", deptName, averageSalary);
});
}

/**
* 测试目标:终极全量统计
* 全量统计的指标包括:count、sum、min、max、average
*/
@Test
public void test05() {
Map<String, DoubleSummaryStatistics> deptNameAndStats = staffList.stream()
.collect(Collectors.groupingBy(
StaffPO::getDeptName,
Collectors.summarizingDouble(StaffPO::getSalary)
));
deptNameAndStats.forEach((deptName, stats) -> {
log.info("{} stats:{}", deptName, stats);
});
}


多维嵌套分组(矩阵)

在日常高难度业务中(比如复杂的动态权限矩阵、多维度的财务账单大盘),单级分组往往不够用。我们需要在分好组的数据盒子里,再次嵌套另一套分组或清洗规则。这里涉及到两个关键的操作:多级嵌套分组(套娃分组)分组后提取极值对象(流流转换)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/**
* 测试目标:多级嵌套分组
* 业务场景:先按照“部门名称”分组;在每个部门内部,再按照“性别(M/F)”进行二次分组
*
* groupingBy 的本质是一个普通的收集器,所以它天然可以作为另一个 groupingBy 的第二参数(下游收集器)插入。
* 理论上你可以无限套娃下去(比如:部门 -> 性别 _> 在职状态),但在生产中建议最多嵌套两层,否则代码可读性会急剧下降。
*/
@Test
public void test06() {
Map<String, Map<String, List<StaffPO>>> matrixMap = staffList.stream()
.collect(Collectors.groupingBy(
StaffPO::getDeptName,
Collectors.groupingBy(StaffPO::getGender)
));
matrixMap.forEach((dept, genderMap) -> {
log.info("----部门 {}----", dept);
genderMap.forEach((gender, staffs) -> {
log.info("{}: staffs {}", gender, staffs.stream().map(StaffPO::getName).toList());
});
});
}

/**
* 找出每个部门里面的薪资最高者
*/
@Test
public void test07() {
// 方法一:
Map<String, StaffPO> deptTopEarnerMap = staffList.stream()
.collect(Collectors.toMap(
StaffPO::getDeptName,
Function.identity(),
BinaryOperator.maxBy(Comparator.comparingDouble(StaffPO::getSalary)))); // 同类型二元算子
deptTopEarnerMap.forEach((dept, staff) -> {
log.info("{} 最高薪资获得者:{} ({})", dept, staff.getName(), staff.getSalary());
});

// 方法二:
Map<String, StaffPO> deptTopEarnerMap2 = staffList.stream()
.collect(Collectors.groupingBy(
StaffPO::getDeptName,
Collectors.collectingAndThen(
Collectors.maxBy(Comparator.comparingDouble(StaffPO::getSalary)), // down stream collector
Optional::get // finisher function
)
));
deptTopEarnerMap2.forEach((dept, staff) -> {
log.info("{} 最高薪资获得者2:{} ({})", dept, staff.getName(), staff.getSalary());
});
}


多表关联

将 List<Order> 和 List<User> 聚合成一个报表,要求输出:Map<String, Double> (部门名称 -> 该部门所有员工订单总金额)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public Map<String, Double> getDeptOrderAmount(List<Order> orders, List<User> users) {
// 1. 将用户列表转为 Map,用于快速查找 (userId -> User)
Map<Long, User> userMap = users.stream()
.collect(Collectors.toMap(User::getUserId, u -> u, (u1, u2) -> u1));

// 2. 流式聚合处理
return orders.stream()
// 约束:过滤掉 null 金额的订单
.filter(order -> Objects.nonNull(order.getAmount()))
// 关联:找到对应的部门名称 (使用 Optional 处理找不到用户的情况)
.filter(order -> userMap.containsKey(order.getUserId()))
.collect(Collectors.groupingBy(
order -> userMap.get(order.getUserId()).getDeptName(),
// 下游收集器:对金额求和
Collectors.summingDouble(Order::getAmount)
));
}

在上面的代码中,如果你有订单存在但用户已被删除(userMap.get() 返回 null),代码会抛出 NullPointerException。标准做法是在 groupingBy 的分组键位置,我们需要加入防御性编程:

1
2
3
4
5
6
.collect(Collectors.groupingBy(
order -> Optional.ofNullable(userMap.get(order.getUserId()))
.map(User::getDeptName)
.orElse("Unknown"), // 处理未关联到部门的情况
Collectors.summingDouble(Order::getAmount)
));

遇到 downstream 这种写法,比如如果你的下游逻辑更复杂(比如:你既要平均值,又要最大值),此时一个 averagingDouble 就不够用了,你需要用到 Collectors.collectingAndThen 或 自定义对象封装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 1. 定义一个简单的统计结果类
record DeptStats(double avgAmount, long count) {}

// 2. 使用 Downstream 进行组合
Map<String, DeptStats> complexStats = orders.stream()
.collect(Collectors.groupingBy(
o -> userMap.get(o.getUserId()).getDeptName(),
Collectors.collectingAndThen(
Collectors.toList(), // 先把桶里的 Order 转为 List
list -> new DeptStats(
list.stream().mapToDouble(Order::getAmount).average().orElse(0.0),
list.size()
)
)
));